Skip to content

Bun.serve: close TCP listen socket when HTTP/3 bind fails - #32544

Closed
robobun wants to merge 1 commit into
mainfrom
farm/d736dc7a/fix-h3-listen-fail-teardown
Closed

robobun wants to merge 1 commit into
mainfrom
farm/d736dc7a/fix-h3-listen-fail-teardown

Conversation

@robobun

@robobun robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

When Bun.serve({http3: true}) succeeds at binding the TCP port but fails to bind the matching UDP port for QUIC, listen() throws and calls deinit() on the server. deinit() destroys the uws App, which runs us_socket_group_deinit() on the HttpContext group.

Debug builds abort there because the TCP listen socket is still linked in head_listen_sockets:

bun-debug: ../../packages/bun-usockets/src/context.c:67: void us_socket_group_deinit(struct us_socket_group_t *): Assertion `group->head_listen_sockets == ((void*)0)' failed.

Release builds leak the fd (the TCP port stays bound) and leave the listen socket pointing into freed group storage.

Fix: close the listen socket in deinit() before destroying the App. Every other caller of deinit() reaches it with self.listener == None (either listen never ran, or deinit_if_we_can() gated on !has_listener()), so this only changes the listen-failure path.

The test reproduces by reserving a UDP port and asking Bun.serve to use that exact TCP+UDP port pair; TCP succeeds, UDP fails. It then re-binds TCP on the same port to prove the fd was released.

Found by Fuzzilli.

When Bun.serve({http3: true}) succeeds at binding the TCP port but fails
to bind the matching UDP port for QUIC, listen() throws and calls
deinit() on the server. deinit() destroys the uws App, which runs
us_socket_group_deinit() on the HttpContext group. Debug builds abort
there because the TCP listen socket is still linked in
head_listen_sockets; release builds leak the fd (the port stays bound)
and leave the listen socket pointing into freed group storage.

Close the listen socket in deinit() before destroying the App.
@robobun

robobun commented Jun 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:11 AM PT - Jun 20th, 2026

❌ @robobun, your commit ca41614 has 1 failures in Build #63661 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32544

That installs a local version of the PR into your bun-32544 executable, so you can run:

bun-32544 --bun

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f8d2340f-41e9-49dc-8b27-926c409edb6b

📥 Commits

Reviewing files that changed from the base of the PR and between 8841747 and ca41614.

📒 Files selected for processing (2)
  • src/runtime/server/mod.rs
  • test/js/bun/http/serve-http3.test.ts

Walkthrough

NewServer::deinit is updated to explicitly close h3_listener and the TCP listener when cleanup is triggered after a successful TCP bind but failed H3 UDP bind. A regression test verifies this fix by engineering the failure scenario and confirming the TCP port can be rebound.

Changes

TCP socket cleanup on H3 bind failure

Layer / File(s) Summary
deinit cleanup and regression test
src/runtime/server/mod.rs, test/js/bun/http/serve-http3.test.ts
NewServer::deinit now calls take() and close() on h3_listener before destroying h3_app, and adds an explicit close() on the TCP listener in the path where TCP bind succeeded but H3 UDP bind failed. The regression test forces a UDP port collision, asserts Bun.serve throws, and verifies the TCP port is rebindable (confirming no FD leak).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: closing the TCP listen socket when HTTP/3 UDP binding fails during Bun.serve startup.
Description check ✅ Passed The description provides comprehensive context about the bug, root cause, fix approach, and testing strategy, going well beyond the basic template requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Bun.serve: close listen socket before destroying app in deinit() #32543 - Also closes listen sockets in deinit() before destroying the uws App to fix the same assertion failure and fd leak
  2. server: close TCP listener when H3 UDP bind fails #32539 - Fixes the same bug where TCP listen socket leaks when HTTP/3 UDP bind fails

🤖 Generated with Claude Code

@robobun

robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #32539 / #32543, which were opened earlier with equivalent fixes.

@robobun robobun closed this Jun 20, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — targeted fix that closes the TCP/H3 listen sockets in deinit() before destroying the uws App, matching the pattern already used in the port-0 retry loop.

Extended reasoning...

Overview

This PR adds two if let Some(...) = take() { close() } blocks to NewServer::deinit() in src/runtime/server/mod.rs, closing the TCP listen socket (and, defensively, the H3 listen socket) before the owning uws App is destroyed. It also adds a regression test in test/js/bun/http/serve-http3.test.ts that forces the TCP-succeeds/UDP-fails path and asserts the TCP port is released.

Correctness

I traced every Self::deinit(this) call site. All the ones in the 2480–2670 range run during setup before listen(), so listener/h3_listener are None. The one inside deinit_if_we_can() (mod.rs:1717) is gated on !has_listener(). The remaining caller at mod.rs:2845 is the post-listen exception path this PR targets — the only place deinit() could be reached with listener == Some. The new code uses .take() so it composes safely with stop_listening(), and the opaque_deref_mut(ls).close() idiom is identical to the existing usage in the port-0 retry loop at mod.rs:2792-2793. Closing the listener before NewApp::destroy is the correct order for us_socket_group_deinit's head_listen_sockets == NULL invariant.

Security risks

None. This is resource-cleanup ordering on an error path; no new inputs, no auth/crypto/parsing changes.

Level of scrutiny

Low–medium. ~11 lines of production code following an established pattern, plus a self-contained subprocess test. The fix is narrowly scoped to a fuzzer-found teardown ordering bug and cannot affect the happy path (listeners are already None there).

Other factors

The test is well-designed: it runs in a subprocess so a debug-build assertion abort wouldn't take down the test runner, asserts on the specific throw message, proves fd release via a TCP rebind, and checks stderr for the assertion string. No CODEOWNERS entry covers this file and there are no outstanding reviewer comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant